起因:Wayland 上没有 set_visible#
在开发 Rustle(自己的一个音乐软件)时,我需要实现 "最小化到系统托盘" 功能:
- 点击关闭按钮时,窗口隐藏而不是退出程序
- 点击托盘图标时,窗口重新显示
- 程序在后台继续运行(daemon 模式)
虽然使用 daemon 模式可以做到后台运行,但是 Iced 和 winit 的默认策略是关掉整个窗口,再需要时再唤出,但是由于我打算使用 GPU 渲染而不是软渲染,所以创建一个新的 GPU 上下文再重新初始化 vulkan 生命周期再调用 wgpu 绘制软件界面,这个冷启动的过程足足有 500ms!所以必须保存 vulkan/opengl 生命周期,则不能销毁窗口,而是令其不可见。
在 X11 上,这很简单 —— 调用 window.set_visible(false/true) 即可。但在 Wayland 上:
// winit 的 Wayland 实现
pub fn set_visible(&self, _visible: bool) {
// Not possible on Wayland.
}
winit 直接放弃了这个功能,注释写着 "Wayland 上不可能实现"。(?怎么可能)
查阅 Wayland 协议文档后发现,Wayland 的设计哲学与 X11 截然不同:
- 没有全局窗口管理器 API:客户端不能直接操作窗口的显示状态
- Compositor 主导一切:窗口的显示、隐藏、位置都由 compositor 决定
- 只有
set_minimized:但这个操作是单向的—— 程序无法通过代码恢复最小化的窗口
翻遍全网也没找到一样的问题。但真的没有办法吗?
探索:GTK、Chromium 是怎么做的?#
GTK 的实现#
查看 GTK 源码发现了关键线索:
// gdk/wayland/gdkwindow-wayland.c
static void gdk_wayland_window_hide(GdkWindow *window) {
GdkWindowImplWayland *impl = GDK_WINDOW_IMPL_WAYLAND(window->impl);
wl_surface_attach(impl->display_server.wl_surface, NULL, 0, 0);
wl_surface_commit(impl->display_server.wl_surface);
_gdk_window_clear_update_area(window);
}
关键发现:GTK 通过 wl_surface_attach(NULL) 来隐藏窗口!
XDG Shell 协议规范#
查阅 XDG Shell 协议文档,找到了官方说明:
Attaching a null buffer to a toplevel unmaps the surface.
The client can re-map the toplevel by performing a commit without any buffer attached, waiting for a configure event and handling it as usual.
这意味着:
- 隐藏:
attach(NULL)+commit()→ surface 被 unmap - 显示:
commit()→ 触发 configure event → 重新渲染
Chromium 的实现#
进一步研究 Chromium 的 Wayland 实现:
// WaylandToplevelWindow::Hide()
void WaylandToplevelWindow::Hide() {
shell_toplevel_.reset(); // 销毁 xdg_toplevel
connection()->buffer_manager_host()->ResetSurfaceContents(root_surface());
}
// WaylandToplevelWindow::Show()
void WaylandToplevelWindow::Show(bool inactive) {
if (!CreateShellToplevel()) { ... } // 重新创建 xdg_toplevel
}
Chromium 采用了更激进的方案 —— 销毁并重建 xdg_toplevel。但我后来发现这种方式在 Hyprland 上会导致 compositor 崩溃(这对吗?)。
实现:修改 winit#
架构概览#
┌────────────────────────────┐
│ Rustle (应用层) │
├────────────────────────────┤
│ iced (GUI 框架) │
├────────────────────────────┤
│ iced_winit (窗口管理) │
├────────────────────────────┤
│ winit (窗口抽象) │
├────────────────────────────┤
│ smithay-client-toolkit (Wayland 封装) │
├────────────────────────────┤
│ wayland-client (协议绑定) │
├────────────────────────────┤
│ Wayland Compositor │
└────────────────────────────┘
需要修改的层:
- iced: 添加
set_visibleAPI - winit: 实现 Wayland 上的
set_visible
winit 的修改#
核心实现(src/platform_impl/linux/wayland/window/mod.rs):
pub fn set_visible(&self, visible: bool) {
// 根据 XDG Shell 协议:
// - "Attaching a null buffer to a toplevel unmaps the surface."
// - "The client can re-map the toplevel by performing a commit without any
// buffer attached, waiting for a configure event and handling it as usual."
let surface = self.window.wl_surface();
if visible {
{
let mut state = self.window_state.lock().unwrap();
state.set_visible(true);
// 重置 frame callback 状态,打破死锁
state.frame_callback_reset();
}
surface.commit();
self.request_redraw();
} else {
self.window_state.lock().unwrap().set_visible(false);
// 清空待处理的 redraw 请求
self.window_requests.redraw_requested.store(false, Ordering::Relaxed);
// 按协议 unmap:attach(NULL) + commit
surface.attach(None, 0, 0);
surface.commit();
}
}
iced 的修改#
添加 Action(runtime/src/window.rs):
pub enum Action {
// ...existing actions...
/// Set the visibility of the window.
SetVisible(Id, bool),
}
/// Sets the visibility of the window.
pub fn set_visible<T>(id: Id, visible: bool) -> Task<T> {
task::effect(crate::Action::Window(Action::SetVisible(id, visible)))
}
处理 Action(winit/src/lib.rs):
window::Action::SetVisible(id, visible) => {
if let Some(window) = window_manager.get_mut(id) {
window.raw.set_visible(visible);
}
}
不是哥们:那些令人头疼的 Bug#
Hyprland Compositor 崩溃(为什么不能销毁 xdg_toplevel?)#
最初的方案:参考 Chromium 的实现,销毁 xdg_toplevel 来隐藏窗口,重建它来显示窗口。
问题:在 Hyprland 上,销毁并重建 xdg_toplevel 会导致 compositor 崩溃,回到 SDDM 界面
// Hyprland 崩溃堆栈
CWindow::create(CXDGSurfaceResource)
CWLSurface::assign
CWLSurface::init // 崩溃点
根本原因:Hyprland 不能正确处理在同一个 xdg_surface 上重新创建 xdg_toplevel 的情况。
最终方案:完全避免销毁 xdg_toplevel,只使用 XDG Shell 协议规定的 wl_surface.attach(NULL) 方法:
- 隐藏:
attach(NULL)+commit()→ surface 被 unmap - 显示:
commit()+request_redraw()→ 重新渲染
这个方案:
- 完全符合 XDG Shell 协议
- 不破坏
xdg_toplevel生命周期 - 兼容所有 compositor(包括 Hyprland)
- 代码更简洁,不需要复杂的生命周期管理
隐藏后无法恢复显示#
现象:set_visible(false) 成功隐藏窗口,但 set_visible(true) 后窗口不出现。
原因:Frame callback 死锁。
┌───────────────────────────┐
│ wgpu 等待 frame callback 才能提交 buffer │
│ ↓ │
│ compositor 等待 buffer 才能发送 frame callback │
│ ↓ │
│ 死锁! │
└───────────────────────────┘
当窗口隐藏(attach(NULL))后,compositor 不再发送 frame callback。但 winit 的渲染循环依赖 frame callback 来知道何时渲染下一帧。
解决方案:在 set_visible(true) 时重置 frame callback 状态:
state.frame_callback_reset(); // 重置为 None,允许立即重绘
隐藏时窗口闪烁#
现象:set_visible(false) 时窗口消失后又闪现一次,而且每次闪现的次数会累积。
原因:Client-Side Decorations (CSD) 的刷新逻辑。
winit 的事件循环会周期性调用 refresh_frame() 来更新窗口装饰。即使窗口已经隐藏,如果 CSD 框架认为自己是 "dirty" 的,它仍然会触发重绘 —— 这会重新 attach buffer,导致窗口又出现。
解决方案:多层防护:
// 1. refresh_frame() 中检查 visible
pub fn refresh_frame(&mut self) -> bool {
if !self.visible {
return false; // 隐藏时不刷新装饰
}
// ...
}
// 2. request_redraw() 中检查 visible
pub fn request_redraw(&self) {
if !self.window_state.lock().unwrap().visible() {
return; // 隐藏时不请求重绘
}
// ...
}
// 3. set_visible(false) 时清空 pending redraw
self.window_requests.redraw_requested.store(false, Ordering::Relaxed);
// 4. event loop 派发时检查 visible
if !window.visible() {
window_requests.get(window_id).unwrap().take_redraw_requested();
return None; // 不派发 RedrawRequested
}
最终方案总结#
核心原理#
隐藏窗口:
┌────────────────────────┐
│ 1. set_visible(false) │
│ 2. 设置 visible 状态为 false │
│ 3. 清空 pending redraw 请求 │
│ 4. wl_surface.attach(NULL, 0, 0) │
│ 5. wl_surface.commit() │
│ → Surface 被 unmap,compositor 不再显示它 │
└────────────────────────┘
显示窗口:
┌────────────────────────┐
│ 1. set_visible(true) │
│ 2. 设置 visible 状态为 true │
│ 3. 重置 frame_callback_state (打破死锁) │
│ 4. wl_surface.commit() │
│ 5. request_redraw() │
│ → 触发重绘,wgpu attach buffer,窗口重新出现 │
└────────────────────────┘
修改的文件#
| 项目 | 文件 | 修改内容 |
|---|---|---|
| winit | src/.../wayland/window/mod.rs | 实现 set_visible();在 request_redraw() 中检查 visible |
| winit | src/.../wayland/window/state.rs | 添加 visible 字段;在 refresh_frame() 中检查 visible |
| winit | src/.../wayland/event_loop/mod.rs | 在 RedrawRequested 派发前检查 visible 并清空 pending redraw |
| iced | runtime/src/window.rs | 添加 SetVisible action 和 set_visible() 函数 |
| iced | winit/src/lib.rs | 处理 SetVisible action,调用 window.raw.set_visible() |
使用方式#
// 在 iced 应用中
Message::ToggleWindow => {
self.window_hidden = !self.window_hidden;
let visible = !self.window_hidden;
return iced::window::latest().and_then(move |id| {
iced::window::set_visible(id, visible)
});
}
参考资料#
协议文档#
源码参考#
相关 Issue#
作者注:本实现基于 winit 0.30.12、iced 0.14.0。不同版本可能需要调整。
代码仓库:
- Rustle: https://github.com/ArcticFoxNetwork/Rustle
- winit fork: https://github.com/ArcticFoxNetwork/winit (wayland-visibility 分支)
- iced fork: https://github.com/ArcticFoxNetwork/iced (wayland-visibility 分支)